/*           Copyright(c) 2005-2008 Dave Sexton            *
 *                   HtmlEditor Control                    *
 * http://www.codeplex.com/DocProject/Project/License.aspx *
 ***********************************************************/
using System;
using System.Collections.Generic;
using System.Text;
using System.IO;
using System.Xml;
using DaveSexton.Controls.Resources;

namespace DaveSexton.Controls
{
	public delegate string HtmlToXmlConverterCallback(string html, XmlNamespaceManager namespaces);
	public delegate string XmlNameProcessor(string name);

	/// <remarks>
	/// This class is not thread-safe.
	/// </remarks>
	public sealed partial class HtmlToXmlConverter : HtmlParserBase, IDisposable
	{
		#region Public Properties
		public XmlWriter Output
		{
			get
			{
				if (disposed)
					throw new ObjectDisposedException(this.GetType().FullName);

				return writer;
			}
		}

		public bool IgnoreUnmatchedEndTags
		{
			get
			{
				return ignoreUnmatchedEndTags;
			}
			set
			{
				ignoreUnmatchedEndTags = value;
			}
		}

		public XmlNamespaceManager Namespaces
		{
			get
			{
				return namespaces;
			}
		}

		public bool RemoveTagPrefixes
		{
			get
			{
				return removeTagPrefixes;
			}
			set
			{
				removeTagPrefixes = value;
			}
		}

		public XmlNameProcessor TagNameProcessor
		{
			get
			{
				return tagNameProcessor;
			}
			set
			{
				tagNameProcessor = value;
			}
		}

		public XmlNameProcessor AttributeNameProcessor
		{
			get
			{
				return attributeNameProcessor;
			}
			set
			{
				attributeNameProcessor = value;
			}
		}
		#endregion

		#region Private / Protected
		private bool ignoreUnmatchedEndTags = true, removeTagPrefixes;
		private volatile bool disposed;
		private XmlNameProcessor tagNameProcessor, attributeNameProcessor;
		private readonly bool ownsWriter = true;
		private readonly XmlNamespaceManager namespaces;
		#endregion

		#region Constructors
		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(TextWriter output)
			: base(XmlWriter.Create(output))
		{
			namespaces = new XmlNamespaceManager(new NameTable());
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(TextWriter output, XmlWriterSettings settings)
			: base(XmlWriter.Create(output, settings))
		{
			namespaces = new XmlNamespaceManager(new NameTable());
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(XmlWriter output, bool ownsWriter)
			: base(XmlWriter.Create(output))
		{
			this.ownsWriter = ownsWriter;
			namespaces = new XmlNamespaceManager(new NameTable());
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(TextWriter output, XmlNamespaceManager namespaces)
			: base(XmlWriter.Create(output))
		{
			if (namespaces == null)
				throw new ArgumentNullException("namespaces");

			this.namespaces = namespaces;
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(TextWriter output, XmlWriterSettings settings, XmlNamespaceManager namespaces)
			: base(XmlWriter.Create(output, settings))
		{
			if (namespaces == null)
				throw new ArgumentNullException("namespaces");

			this.namespaces = namespaces;
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(XmlWriter output, bool ownsWriter, XmlNamespaceManager namespaces)
			: base(XmlWriter.Create(output))
		{
			if (namespaces == null)
				throw new ArgumentNullException("namespaces");

			this.ownsWriter = ownsWriter;
			this.namespaces = namespaces;
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(StringBuilder output)
			: base(XmlWriter.Create(output))
		{
			namespaces = new XmlNamespaceManager(new NameTable());
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(StringBuilder output, XmlWriterSettings settings)
			: base(XmlWriter.Create(output, settings))
		{
			namespaces = new XmlNamespaceManager(new NameTable());
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(StringBuilder output, bool ownsWriter)
			: base(XmlWriter.Create(output))
		{
			this.ownsWriter = ownsWriter;
			namespaces = new XmlNamespaceManager(new NameTable());
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(StringBuilder output, XmlNamespaceManager namespaces)
			: base(XmlWriter.Create(output))
		{
			if (namespaces == null)
				throw new ArgumentNullException("namespaces");

			this.namespaces = namespaces;
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(StringBuilder output, XmlWriterSettings settings, XmlNamespaceManager namespaces)
			: base(XmlWriter.Create(output, settings))
		{
			if (namespaces == null)
				throw new ArgumentNullException("namespaces");

			this.namespaces = namespaces;
		}

		/// <summary>
		/// Constructs a new instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		public HtmlToXmlConverter(StringBuilder output, bool ownsWriter, XmlNamespaceManager namespaces)
			: base(XmlWriter.Create(output))
		{
			if (namespaces == null)
				throw new ArgumentNullException("namespaces");

			this.ownsWriter = ownsWriter;
			this.namespaces = namespaces;
		}
		#endregion

		#region Methods
		public static string Encode(string text)
		{
			return System.Web.HttpUtility.HtmlEncode(text);
		}

		public static string Decode(string html)
		{
			return System.Web.HttpUtility.HtmlDecode(html);
		}

		public static string ConvertDocument(string html, XmlWriterSettings settings)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer, settings))
				{
					converter.removeTagPrefixes = true;
					converter.Convert(Environment.NewLine + html);

					return writer.ToString();
				}
			}
		}

		public static string ConvertDocument(string html)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer))
				{
					converter.removeTagPrefixes = true;
					converter.Convert(Environment.NewLine + html);

					return writer.ToString();
				}
			}
		}

		public static string ConvertDocument(string html, bool indent)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				XmlWriterSettings settings = new XmlWriterSettings();
				settings.Indent = indent;

				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer, settings))
				{
					converter.removeTagPrefixes = true;
					converter.Convert(Environment.NewLine + html);

					return writer.ToString();
				}
			}
		}

		public static string ConvertFragment(string html, bool indent)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				XmlWriterSettings settings = new XmlWriterSettings();
				settings.Indent = indent;
				settings.ConformanceLevel = ConformanceLevel.Fragment;

				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer, settings))
				{
					converter.removeTagPrefixes = true;
					converter.Convert(html);

					return writer.ToString();
				}
			}
		}

		public static string ConvertDocument(string html, XmlWriterSettings settings, XmlNamespaceManager namespaces)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer, settings, namespaces))
				{
					converter.Convert(Environment.NewLine + html);

					return writer.ToString();
				}
			}
		}

		public static string ConvertDocument(string html, XmlNamespaceManager namespaces)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer, namespaces))
				{
					converter.Convert(Environment.NewLine + html);

					return writer.ToString();
				}
			}
		}

		public static string ConvertDocument(string html, bool indent, XmlNamespaceManager namespaces)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				XmlWriterSettings settings = new XmlWriterSettings();
				settings.Indent = indent;

				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer, settings, namespaces))
				{
					converter.Convert(Environment.NewLine + html);

					return writer.ToString();
				}
			}
		}

		public static string ConvertFragment(string html, bool indent, XmlNamespaceManager namespaces)
		{
			using (StringWriter writer = new StringWriter(System.Globalization.CultureInfo.CurrentCulture))
			{
				XmlWriterSettings settings = new XmlWriterSettings();
				settings.Indent = indent;
				settings.ConformanceLevel = ConformanceLevel.Fragment;

				using (HtmlToXmlConverter converter = new HtmlToXmlConverter(writer, settings, namespaces))
				{
					converter.Convert(html);

					return writer.ToString();
				}
			}
		}

		public void Convert(string html)
		{
			if (disposed)
				throw new ObjectDisposedException(this.GetType().FullName);

			if (html == null)
				throw new ArgumentNullException("html");

			if (html.Length > 0)
			{
				using (StringReader reader = new StringReader(html))
				{
					Convert(reader);
				}
			}
		}

		public void Convert(TextReader html)
		{
			if (disposed)
				throw new ObjectDisposedException(this.GetType().FullName);

			if (html == null)
				throw new ArgumentNullException("html");

			this.reader = html;
			
			TagParser tag = null;
			CommentReader comments = new CommentReader(reader, writer);
			
			while (!AtEndOfStream())
			{
				bool skip;
				string name;
				switch (MoveToNext(false, out name))
				{
					case XmlNodeType.Element:
						skip = !ProcessTagName(ref name);

						if (!skip && string.Equals(name, "script", StringComparison.OrdinalIgnoreCase))
							throw new ArgumentException(Errors.HtmlToXmlConverterScriptTagNotSupported, "html");

						tag = new TagParser(reader, writer, name, attributeNameProcessor, tag, removeTagPrefixes, namespaces, offsetTags, singleTags, booleanAttributes);
						tag.Skip = skip;

						tag.ParseStartElement();		// will parse entire element if it only has a single tag (e.g., <tagName />)
						break;
					case XmlNodeType.EndElement:
						// Look for the matching start tag.
						// First, it can't be a single-tag element...
						while (tag != null && tag.IsSingleTagElement)
							tag = tag.Parent;

						skip = !ProcessTagName(ref name);

						if (tag == null)
						// missing start tag at the root level
						{
							if (offsetTags.ContainsKey(name) && offsetTags[name].Count > 0 && offsetTags[name].Peek() != tag)
							// offset end tag was found inside an element on an earlier level than where the start tag was defined.
							// example:   <p><font></p></font>   -- assuming "p" is not a registered offset tag and "font" is
							{
								offsetTags[name].Pop();

								SkipTo(true, false, TagEnd);
								SkipNext();		// skip tag name end character
								break;
							}
							else if (!ignoreUnmatchedEndTags)
								throw new ArgumentException(string.Format(System.Globalization.CultureInfo.CurrentCulture,
									Errors.HtmlToXmlConverterNoMatchingStartTag, name), "html");
							else
							{
								// skip closing '>' character
								reader.Read();
								break;
							}
						}
						else if (!string.Equals(tag.Name, name, StringComparison.OrdinalIgnoreCase))
						// The end tag on which the reader is currently positioned (name) does not match the current element that is in scope (tag).
						// Assume that the current "tag" is missing an end element.
						// Example:  <ul><li></ul>		-- reader is positioned on </ul, tag is li, name is ul but li was expected
						{
							// move the start element for the current end tag (name)
							// (in the example above, move to ul, closing all child elements that haven't been closed, such as li)
							if (!MoveToStart(name, ref tag))
								// missing start tag - skip end tag (name)
								break;
						}

						if (tag.Name != null && offsetTags.ContainsKey(tag.Name) && offsetTags[tag.Name].Count > 0)
							offsetTags[tag.Name].Pop();

						tag.WriteEndElement();
						tag = tag.Parent;
						break;
					case XmlNodeType.Comment:
						writer.WriteComment(comments.Read());
						break;
					case XmlNodeType.DocumentType:
						SkipTo(true, false, TagEnd);
						SkipNext();		// skip >
						SkipToContent(true, true);	// whitespace after the DTD must be ignored
						break;
				}
			}

			while (tag != null)
			{
				if (!tag.IsSingleTagElement)
					writer.WriteEndElement();

				tag = tag.Parent;
			}

			// In testing, writer.ToString() (when writer is a StringWriter instance) may return an empty
			// string until Flush is called, even after the entire parse operation has completed.
			writer.Flush();

			//writer.WriteEndDocument();
		}

		private bool ProcessTagName(ref string name)
		{
			if (tagNameProcessor != null)
			{
				string result = tagNameProcessor(name);

				// NOTE: If name is null that will indicate to the TagParser class that the element should be skipped (read, but not written).
				// However, child elements are still added to the current element's parent.
				// Returning null for the root element (from tagNameParser) may cause an exception later if there ends up being multiple root elements, 
				// unless a fragment is being written.
				if (result == null)
					return false;
				else
					name = result;
			}

			return true;
		}

		private bool MoveToStart(string endTag, ref TagParser tag)
		{
			// In cases where tags that are not marked as offsets start before an offset tag ends, but do not end themselves, then
			// the start tag will end immediately and it will effectively be "removed" as the container of everything after the offset 
			// end tag.  The current offset tag is set to the local variable "endTag" (font, in the example).
			// example: <font> <p></font> <span>text</span> </p>			-- assuming that "p" is not a registered offset tag and "font" is
			// becomes: <font> <p /> <span>text</span> </font>
			bool offsetTagObstruction = offsetTags.ContainsKey(endTag) && offsetTags[endTag].Count > 0;

			TagParser tempTag, startTag = tempTag = tag;
			Queue<TagParser> tagsToEnd = new Queue<TagParser>();

			// Close the start tag and each parent element until the start tag that corresponds with the endTag is reached.
			do
			{
				if (!offsetTagObstruction || offsetTags[endTag].Peek().Contains(startTag))
					// offsetTags contains startTag: 
					//   Close the obstructed tag since it doesn't have an end tag inside of the offset tag's scope (it's actual close tag will be ignored).
					// !offsetTagObstruction: 
					//   A standard tag must be closed before the cursor is moved to an earlier position in the document (i.e., its parent), 
					//   even if an end tag does not explicitly exist.  For a given document, if you expect that the current tag may be closed 
					//   in an offset-manner, whereby a closing tag appears inside a sibling element of its parent, instead of inside the parent 
					//   itself, then the tag name should be added to the ParserBase.AllowableOffsetTagNames collection to avoid this code.
					tagsToEnd.Enqueue(startTag);

				startTag = startTag.Parent;

				if (startTag == null)
				// the endTag on which the reader is currently positioned is missing a start tag
				// - ignore it
				{
					SkipTo(true, false, TagEnd);
					SkipNext();		// skip tag name end character
					return false;
				}
				else
					// keep track of the current tag as each parent element is closed
					tempTag = startTag;
			}
			while (!string.Equals(startTag.Name, endTag, StringComparison.OrdinalIgnoreCase));

			// close all elements that are missing end tags
			foreach (TagParser openTag in tagsToEnd)
				openTag.CreateEndElement();

			tag = tempTag;

			return true;
		}

    [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2208:InstantiateArgumentExceptionsCorrectly")]
		private XmlNodeType MoveToNext(bool trimLeadingWhiteSpace, out string name)
		{
			name = null;

			if (!MoveTo(true, trimLeadingWhiteSpace, TagStart))
				return XmlNodeType.None;

			SkipNext();		// skip tag name start character

			bool isEndTag = false;

			if (PeekNextCharacter() == ElementEnd)
			{
				SkipNext();		// skip end tag character

				isEndTag = true;

				name = ReadName(false, TagEnd);
			}
			else if (PeekNextCharacter() == CommentStart)
			{
				SkipNext();		// skip comment character

				char next = ReadNext();

				if (next == 'D' || next == 'd')
				{
					if (!string.Equals(base.ReadName(true), "OCTYPE", StringComparison.OrdinalIgnoreCase))
						throw new ArgumentException(string.Format(System.Globalization.CultureInfo.CurrentCulture,
							Errors.HtmlToXmlConverterInvalidTagName, "<!"), "html");
					else
						return XmlNodeType.DocumentType;
				}
				else if (next != '-' || ReadNext() != '-')
				{
					throw new ArgumentException(string.Format(System.Globalization.CultureInfo.CurrentCulture,
						Errors.HtmlToXmlConverterInvalidTagName, "<!"), "html");
				}

				return XmlNodeType.Comment;
			}
			else
				// Read element.
				// Name ends with either / as in <br/>, white-space as in <br /> or the end of the tag, as in <br>.
				name = ReadName(true, ElementEnd, TagEnd);

			return (isEndTag) ? XmlNodeType.EndElement : XmlNodeType.Element;
		}

		private bool AtEndOfStream()
		{
			return reader.Peek() == -1;
		}
		#endregion

		#region IDisposable Members
		/// <summary>
		/// Releases all resources used by an instance of the <see cref="HtmlToXmlConverter" /> class.
		/// </summary>
		/// <remarks>
		/// This method calls the virtual <see cref="Dispose(bool)" /> method, passing in <strong>true</strong>, and then suppresses 
		/// finalization of the instance.
		/// </remarks>
		public void Dispose()
		{
			Dispose(true);
			GC.SuppressFinalize(this);
		}

		/// <summary>
		/// Releases unmanaged resources before an instance of the <see cref="HtmlToXmlConverter" /> class is reclaimed by garbage collection.
		/// </summary>
		/// <remarks>
		/// This method releases unmanaged resources by calling the virtual <see cref="Dispose(bool)" /> method, passing in <strong>false</strong>.
		/// </remarks>
		~HtmlToXmlConverter()
		{
			Dispose(false);
		}

		/// <summary>
		/// Releases the unmanaged resources used by an instance of the <see cref="HtmlToXmlConverter" /> class and optionally releases the managed resources.
		/// </summary>
		/// <param name="disposing"><strong>true</strong> to release both managed and unmanaged resources; <strong>false</strong> to release only unmanaged resources.</param>
		private void Dispose(bool disposing)
		{
			if (disposing && ownsWriter)
				writer.Close();

			disposed = true;
		}
		#endregion
	}
}
